D:\a\csshw\csshw\xtask\src\readme.rs
Line | Count | Source |
1 | | //! README help-section verification and update logic. |
2 | | //! |
3 | | //! The README embeds the `--help` output between two HTML comment delimiters: |
4 | | //! |
5 | | //! ```text |
6 | | //! <!-- HELP_OUTPUT_START --> |
7 | | //! ```cmd |
8 | | //! csshw.exe --help |
9 | | //! <help content> |
10 | | //! ``` |
11 | | //! <!-- HELP_OUTPUT_END --> |
12 | | //! ``` |
13 | | //! |
14 | | //! [`check_readme_help`] fails when the embedded text differs from the live |
15 | | //! output. [`update_readme_help`] rewrites the README when they differ and |
16 | | //! signals the change to the caller so a pre-commit hook can abort. |
17 | | |
18 | | use anyhow::{bail, Context, Result}; |
19 | | |
20 | | const START_MARKER: &str = "<!-- HELP_OUTPUT_START -->"; |
21 | | const END_MARKER: &str = "<!-- HELP_OUTPUT_END -->"; |
22 | | const PREAMBLE: &str = "\r\n```cmd\r\ncsshw.exe --help\r\n"; |
23 | | const POSTAMBLE: &str = "\r\n```\r\n"; |
24 | | |
25 | | /// All side-effecting operations required by this module. |
26 | | /// |
27 | | /// Implement with mocks in tests to achieve zero filesystem and process |
28 | | /// side-effects. |
29 | | pub trait ReadmeSystem { |
30 | | /// Run `cargo run --package csshw -- --help` and return the captured output. |
31 | | /// |
32 | | /// # Errors |
33 | | /// |
34 | | /// Returns an error if the process cannot be started or exits non-zero. |
35 | | fn get_help_output(&self) -> Result<String>; |
36 | | |
37 | | /// Read the full contents of `README.md`. |
38 | | /// |
39 | | /// # Errors |
40 | | /// |
41 | | /// Returns an error if the file cannot be read. |
42 | | fn read_readme(&self) -> Result<String>; |
43 | | |
44 | | /// Write `content` to `README.md`. |
45 | | /// |
46 | | /// # Errors |
47 | | /// |
48 | | /// Returns an error if the write fails. |
49 | | fn write_readme(&self, content: &str) -> Result<()>; |
50 | | } |
51 | | |
52 | | /// Production implementation of [`ReadmeSystem`]. |
53 | | pub struct RealSystem; |
54 | | |
55 | | #[cfg_attr(coverage_nightly, coverage(off))] |
56 | | impl ReadmeSystem for RealSystem { |
57 | | fn get_help_output(&self) -> Result<String> { |
58 | | let output = std::process::Command::new("cargo") |
59 | | .args(["run", "--package", "csshw", "--", "--help"]) |
60 | | .output() |
61 | | .context("failed to run `cargo run --package csshw -- --help`")?; |
62 | | let raw = String::from_utf8_lossy(&output.stdout).into_owned(); |
63 | | Ok(raw) |
64 | | } |
65 | | |
66 | | fn read_readme(&self) -> Result<String> { |
67 | | std::fs::read_to_string("README.md").context("failed to read README.md") |
68 | | } |
69 | | |
70 | | fn write_readme(&self, content: &str) -> Result<()> { |
71 | | std::fs::write("README.md", content).context("failed to write README.md") |
72 | | } |
73 | | } |
74 | | |
75 | | /// Normalize raw `--help` output for comparison with the README section. |
76 | | /// |
77 | | /// Replaces lines that contain only whitespace with empty lines, normalizes |
78 | | /// all line endings to `\r\n`, and trims leading and trailing whitespace. |
79 | | /// |
80 | | /// # Arguments |
81 | | /// |
82 | | /// * `raw` - Raw output from `--help`, possibly with mixed line endings. |
83 | | /// |
84 | | /// # Returns |
85 | | /// |
86 | | /// Normalized string ready for comparison with the README section. |
87 | | /// |
88 | 6 | pub fn normalize_help_output(raw: &str) -> String { |
89 | 6 | let normalized: Vec<&str> = raw |
90 | 6 | .lines() |
91 | 9 | .map6 (|line| if line.trim().is_empty() { ""1 } else { line8 }) |
92 | 6 | .collect(); |
93 | 6 | let joined = normalized.join("\r\n"); |
94 | 6 | joined.trim().to_owned() |
95 | 6 | } |
96 | | |
97 | | /// Extract the help text embedded in the README between the delimiters. |
98 | | /// |
99 | | /// # Arguments |
100 | | /// |
101 | | /// * `readme` - Full README contents. |
102 | | /// |
103 | | /// # Returns |
104 | | /// |
105 | | /// The help content string (trimmed), or an error if either delimiter is missing. |
106 | | /// |
107 | | /// # Errors |
108 | | /// |
109 | | /// Returns an error if `<!-- HELP_OUTPUT_START -->` or `<!-- HELP_OUTPUT_END -->` |
110 | | /// is absent, or if the expected preamble/postamble structure is not found. |
111 | 7 | pub fn extract_readme_help_section(readme: &str) -> Result<&str> { |
112 | 7 | let start_marker_pos6 = readme |
113 | 7 | .find(START_MARKER) |
114 | 7 | .context("could not find <!-- HELP_OUTPUT_START --> in README.md")?1 ; |
115 | 6 | let end_marker_pos5 = readme |
116 | 6 | .find(END_MARKER) |
117 | 6 | .context("could not find <!-- HELP_OUTPUT_END --> in README.md")?1 ; |
118 | | |
119 | 5 | let content_start = start_marker_pos + START_MARKER.len() + PREAMBLE.len(); |
120 | 5 | let content_end = end_marker_pos - POSTAMBLE.len(); |
121 | | |
122 | 5 | if content_start > content_end { |
123 | 0 | bail!("README help section delimiters are malformed or out of order"); |
124 | 5 | } |
125 | | |
126 | 5 | Ok(readme[content_start..content_end].trim()) |
127 | 7 | } |
128 | | |
129 | | /// Rebuild the README with the help section replaced by `new_help`. |
130 | | /// |
131 | | /// All content outside the delimiters and the fixed preamble/postamble is |
132 | | /// preserved byte-for-byte. |
133 | | /// |
134 | | /// # Arguments |
135 | | /// |
136 | | /// * `readme` - Full README contents. |
137 | | /// * `new_help` - Normalized help text to embed. |
138 | | /// |
139 | | /// # Returns |
140 | | /// |
141 | | /// New full README string. |
142 | | /// |
143 | | /// # Errors |
144 | | /// |
145 | | /// Returns an error if the delimiters are not found. |
146 | 2 | pub fn replace_readme_help_section(readme: &str, new_help: &str) -> Result<String> { |
147 | 2 | let start_marker_pos = readme |
148 | 2 | .find(START_MARKER) |
149 | 2 | .context("could not find <!-- HELP_OUTPUT_START --> in README.md")?0 ; |
150 | 2 | let end_marker_pos = readme |
151 | 2 | .find(END_MARKER) |
152 | 2 | .context("could not find <!-- HELP_OUTPUT_END --> in README.md")?0 ; |
153 | | |
154 | 2 | let content_start = start_marker_pos + START_MARKER.len() + PREAMBLE.len(); |
155 | 2 | let content_end = end_marker_pos - POSTAMBLE.len(); |
156 | | |
157 | 2 | let before = &readme[..content_start]; |
158 | 2 | let after = &readme[content_end..]; |
159 | | |
160 | 2 | Ok(format!("{before}{new_help}{after}")) |
161 | 2 | } |
162 | | |
163 | | /// Compare the live `--help` output against the README's embedded help section. |
164 | | /// |
165 | | /// Prints a colored diff to stdout when they differ. |
166 | | /// |
167 | | /// # Arguments |
168 | | /// |
169 | | /// * `system` - Injected I/O provider. |
170 | | /// |
171 | | /// # Returns |
172 | | /// |
173 | | /// `Ok(())` if they match; an error describing the mismatch otherwise. |
174 | | /// |
175 | | /// # Errors |
176 | | /// |
177 | | /// Returns an error when the sections differ or when any I/O operation fails. |
178 | 2 | pub fn check_readme_help<S: ReadmeSystem>(system: &S) -> Result<()> { |
179 | 2 | let raw_help = system.get_help_output()?0 ; |
180 | 2 | let actual_help = normalize_help_output(&raw_help); |
181 | | |
182 | 2 | let readme = system.read_readme()?0 ; |
183 | 2 | let readme_help = extract_readme_help_section(&readme)?0 ; |
184 | | |
185 | 2 | if actual_help == readme_help { |
186 | 1 | println!("INFO - README.md help output is up to date."); |
187 | 1 | return Ok(()); |
188 | 1 | } |
189 | | |
190 | 1 | eprintln!("ERROR - README.md help output is outdated!"); |
191 | 1 | eprintln!(); |
192 | 1 | eprintln!("Differences found:"); |
193 | 1 | eprintln!("=================="); |
194 | 1 | eprintln!("README has:"); |
195 | 1 | eprintln!("{readme_help}"); |
196 | 1 | eprintln!(); |
197 | 1 | eprintln!("Current --help output:"); |
198 | 1 | eprintln!("{actual_help}"); |
199 | 1 | eprintln!(); |
200 | 1 | eprintln!("==> Run `cargo xtask update-readme-help` to fix this."); |
201 | | |
202 | 1 | bail!("README.md help output is outdated") |
203 | 2 | } |
204 | | |
205 | | /// Ensure the README's embedded help section matches the live `--help` output, |
206 | | /// writing an updated README when they differ. |
207 | | /// |
208 | | /// # Arguments |
209 | | /// |
210 | | /// * `system` - Injected I/O provider. |
211 | | /// |
212 | | /// # Returns |
213 | | /// |
214 | | /// `Ok(true)` when the README was modified (the caller should exit with code 1 |
215 | | /// to abort a pre-commit hook); `Ok(false)` when already up to date. |
216 | | /// |
217 | | /// # Errors |
218 | | /// |
219 | | /// Returns an error when any I/O operation fails. |
220 | 2 | pub fn update_readme_help<S: ReadmeSystem>(system: &S) -> Result<bool> { |
221 | 2 | let raw_help = system.get_help_output()?0 ; |
222 | 2 | let actual_help = normalize_help_output(&raw_help); |
223 | | |
224 | 2 | let readme = system.read_readme()?0 ; |
225 | 2 | let readme_help = extract_readme_help_section(&readme)?0 ; |
226 | | |
227 | 2 | if actual_help == readme_help { |
228 | 1 | println!("INFO - README.md help section is up to date, nothing to be done."); |
229 | 1 | return Ok(false); |
230 | 1 | } |
231 | | |
232 | 1 | println!("WARNING - README.md help section is outdated — fixing it."); |
233 | 1 | let new_readme = replace_readme_help_section(&readme, &actual_help)?0 ; |
234 | 1 | system.write_readme(&new_readme)?0 ; |
235 | 1 | println!("INFO - README.md help section has been updated with current --help output."); |
236 | | |
237 | 1 | Ok(true) |
238 | 2 | } |
239 | | |
240 | | #[cfg(test)] |
241 | | #[path = "tests/test_readme.rs"] |
242 | | mod tests; |